iT邦幫忙

2023 iThome 鐵人賽

DAY 27
0
Modern Web

深入淺出,完整認識 Next.js 13 !系列 第 27

Day 27 - JavaScript 載入優化:next/dynamic

  • 分享至 

  • xImage
  •  

昨天有提到,根據統計,圖片和 JavaScript 為整體網頁檔案大小佔比的前兩名,兩者加起來佔了近九成。

認識 Next 針對圖片優化推出的 component toolkit - next/image 後,我們接著來看 JavaScript 載入的優化。

還沒讀過 Day 26 圖片優化的讀者,可以點擊文章傳送門


Code Splitting

為了避免瀏覽器花很長時間下載目前頁面用不到的程式碼,使得網頁初始載入時間拖得很長,開發者可以將 JavaScript bundle 拆分成一小塊一小塊的 chunks,讓瀏覽器只載入目前所需要用到的部分,實現 lazy loading 的概念。

將 bundle 拆小的過程就叫做 code splitting。
code splitting
( 圖片來源:https://nextjs.org/learn/foundations/how-nextjs-works/code-splitting )

要做 code splitting,可以使用 Webpack 搭配 CommonsChunkPlugin。這邊就不多做介紹,有興趣的讀者可參考官方文件或其他大大的文章:

Webpack - Code Splitting
莫大 - Day014 X Code Splitting & Dynamic Import

Next 預設的 Code Splitting

Next 預設會在 build-time 時,以路由為單位做 code splitting。

比方說,我們的 app 資料夾中的結構為:

├── dashboard
│   ├── page.tsx
│   └── settings
│       └── page.tsx
├── shop
│   └── page.tsx
├── layout.tsx
├── page.tsx
└── profile
    └── page.tsx

run build 完,到 /.next/static/chunks/app 中可以看到會有 dashboard、profile、shop 三個資料夾,/dashboard 裡會有一個 settings 資料夾,資料夾裡各有一個 page-xxxx.js 檔案。

我們也可以從 terminal 查看各個 route segment 的檔案大小:
build info

那假如我想單獨把某個 component 拆成一個 chunk 呢?

因為 Server Components 預設會做 code splitting,以下方法只適用 Client Components

以 Component 為單位做 Code Splitting

假設想讓某個 component 獨立成一個 chunk,我們可以使用 Day 15 有提到的React.lazy()

舉個簡單範例:

假如頁面有個「打開彈窗」的按鈕,按下去會跳出彈窗,我希望讓彈窗 component <Modal> 打包時獨立成一個 chunk,按下「打開彈窗」後才去載入這個 chunk。就可以用 React.lazy import <Modal>

'use client';
import React from 'react';

const Modal = React.lazy(
  () => import('./components/Modal')
);

export default function Page() {
  const [hasModal, setHasModal] = React.useState<boolean>(false);
  return (
    <div>
      ...
      <button
        onClick={() => setHasModal(true)}
      >
        打開彈窗
      </button>
      {hasModal && <Modal />}
    </div>
  );
}

完成後,我們打開 DevTools 的 Network,查看按鈕點擊後 JS 的 response:
react lazy demo

可以發現,點擊「打開彈窗後」,會新載入一個 _app-pages-xxxxx 的 JS 檔,假如看 requested URL 可以發現的確是來自Modal.tsx 沒錯。

假如希望 chunk 檔名好識別一點,可以在 import 中加入 webpackChunkName 的註解:

const Modal = React.lazy(
  () => import(/* webpackChunkName:"Modal" */ './components/Modal')
);

chunk 的名稱就會改為 Modal:
react lazy demo with custom chunk name

透過 Suspense 加入 loading 效果

我們稍微來讓彈窗複雜一點點,在 <Modal> 中加入讀取 DB 用戶資料的邏輯。

這時 Modal.js 的下載時間就會比較久,這時我們可以使用 <Suspense>,在 Modal.js下載完之前,顯示 loading UI:

/* app/components/Loading.tsx */
export default function Loading() {
  return (
    <>
        ...
        <div className='font-bold text-[30px]'>頁面載入中...</div>
        ...
    </>
  );
}

/* app/page.tsx */
'use client';
import React from 'react';
import Loading from './components/Loading';

const Modal = React.lazy(
  () => import(/* webpackChunkName:"Modal" */ './components/Modal')
);

export default function Page() {
  const [hasModal, setHasModal] = React.useState<boolean>(false);
  return (
    <div className='...'>
      ...
      <button
        onClick={() => setHasModal(true)}
      >
        打開彈窗
      </button>
      {hasModal && (
        <React.Suspense fallback={<Loading />}>
          <Modal />
        </React.Suspense>
      )}
    </div>
  );
}

這樣 Modal.js 下載完前,畫面就會顯示「頁面載入中...」:
lazy loading with suspense

next/dynamic

Next 整合了 React.lazy 和 Suspense,提供一個動態 import 的模組 - next/dynamic

以上述例子來說,我們可以動態匯入 <Modal>

'use client';
import dynamic from 'next/dynamic';
import React from 'react';
import Loading from './components/Loading';

// 一樣可以用 webpackCunkName 命名 chunk
const Modal = dynamic(
  () => import(/* webpackChunkName:"Modal" */ './components/Modal')
);

export default function Page() {
  const [hasModal, setHasModal] = React.useState<boolean>(false);
  return (
    <div className='...'>
      <button
        onClick={() => setHasModal(true)}
      >
        打開彈窗
      </button>
      {hasModal && <Modal />}
    </div>
  );
}

禁用 ssr
如同 Day 14 提到,Next 預設為 Pre-Rendering,假如希望 lazy loaded 的 Client Components 能在瀏覽器渲染,就可以帶入 ssr:false

const Modal = dynamic(
  () => import(/* webpackChunkName:"Modal" */ './components/Modal'),
  { ssr: false }
);

加入 loading 特效
我們也可以在 import 的第二個參數中,加入 loading component,就不需要使用 <Suspense>

const Modal = dynamic(
  () => import(/* webpackChunkName:"Modal" */ './components/Modal'),
  { ssr: false, loading: () => <Loading /> }
);

Named Import
假如要動態匯入非 default export 的 component,可以從 import()模組 return 的 Promise 取得:

/* app/components/Modals.tsx */
export function WelcomeModal() {
  return <h1>Welcome aboard!</h1>;
}
/* app/page.tsx */

const WelcomeModal = dynamic(() =>
  import(/* webpackChunkName:"WelcomeModal" */ './components/Modals').then(
    (mod) => mod.WelcomeModal
  )
);

export default function Page() {
  ...
  return (
    ...
      <WelcomeModal />
    ...
  );
}

Dynamic Import 在 App Router 和 Pages Router 都可以使用

觀察 Bundle 大小變化

最後我們來觀察一下,<Modal> 有沒有使用 lazy loading,頁面初始 JS bundle 的大小差異。

先來看沒有使用 lazy loading 的版本:

/* app/page.tsx */
'use client';
import React from 'react';
import Modal from './components/Modal';

export default function Page() {
  const [isModalOpen, setIsModalOpen] = React.useState<boolean>(false);
  return (
    <div className='...'>
      <h1 className='...'>Lazy Loading Demo</h1>
      <button
        onClick={() => setIsModalOpen(true)}
        className='...'
      >
        打開彈窗
      </button>
      {isModalOpen && <Modal />}
    </div>
  );
}

首頁 (/) 首次載入的 chunk 的大小為 156 KB
bundle size without lazy loading

接著來看使用 lazy loading 的版本:

'use client';
import dynamic from 'next/dynamic';
import React from 'react';

// Modal 使用 dynamic import
const Modal = dynamic(
  () => import(/* webpackChunkName:"Modal" */ './components/Modal')
);

export default function Page() {
  const [isModalOpen, setIsModalOpen] = React.useState<boolean>(false);

  return (
    <div className='...'>
      <h1 className='...'>Lazy Loading Demo</h1>
      <button
        onClick={() => setIsModalOpen(true)}
        className='...'
      >
        打開彈窗
      </button>
      {isModalOpen && <Modal />}
    </div>
  );
}

首頁 (/) 首次載入的 chunk 減少為 84.4 KB
bundle size with lazy loading

第三方 Script 載入優化

假如要載入第三方 script,Next 有提供另個 component toolkit - next/script,針對第三方 script 載入提供幾個優化:

  1. 只有拜訪 script 所在的路由才會載入 script
  2. Script 只會載入一次,透過 soft navigation 拜訪同個 layout 中的其他路由不會重新載入腳本

舉例來說,我今天在 app/profile/layout.tsx 中透過 next/script 嵌入 Firebase SDK:

/* app/profile/layout.tsx */
import Script from 'next/script';

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <div className='...'>
        {children}
      </div>
      <Script src='https://www.gstatic.com/firebasejs/10.4.0/firebase-app.js' />
    </>
  );
}

可以發現,進入首頁時,瀏覽器沒有下載 firebase-app.js,等進到 /profile 或 /profile/settings 才下載。
next/script demo

從 /profile 透過 <Link> 轉到 /profile/settings,firebase-app.js 也不會重新下載:
next/script demo

假如想做更細節的載入設定,比方說想在 hydration 前載入,或是在 web worker 中載入,可以使用 strategy 來 fine tune next/script。篇幅考量,就不多做介紹,有興趣的讀者可以參考官方文件

Webpack Bundle Analyzer

假如想進一步查看每個 chunk 中 packages 的佔比,Next 也有支援 Webpack Bundel Analyzer:

要怎麼使用呢?

  1. 安裝 npm package
  2. 更改 next.config.js 的設定:
    /** @type {import('next').NextConfig} */
    const nextConfig = {
      ...
    };
    const withBundleAnalyzer = require('@next/bundle-analyzer')({
      enabled: process.env.ANALYZE === 'true',
    });
    
    module.exports = withBundleAnalyzer(nextConfig);
    
    
  3. run build 時輸入 ANALYZE=true npm run build,過程會跳出一個瀏覽器視窗,顯示每個 chunk 的 packages 資訊:
    bundle analyzer demo

以上是幾個在 Next 專案中可以優化圖片和 JavaScript 載入的方法,希望對大家有幫助。介紹完 lazy loading 後,下一步會帶大家認識 App Router 中的 chaching 機制。

今天就先到這邊,謝謝大家耐心的閱讀,我們明天見!


上一篇
Day 26 - 圖片優化:next/image
下一篇
Day 28 - Next.js 13 的快取機制 ( 一 ) - Data Cache & Request Memoization
系列文
深入淺出,完整認識 Next.js 13 !30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言